%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2010-2024 Marc Worrell
%% @doc Authentication and identification of users.
%% @end

%% Copyright 2010-2024 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(mod_authentication).
-moduledoc("
This module contains the main Zotonic authentication mechanism. It contains the logon and logoff controllers, and
implements the various hooks as described in the [Access control](/id/doc_developerguide_access_control#guide-auth) manual.

Configuration keys:

mod\\_authentication.password\\_min\\_length

The minimumum length of passwords. Defaults to 8, set this to an integer value.

mod\\_authentication.is\\_rememberme

Set this to `1` to check the *remember me* checkbox per default

mod\\_authentication.is\\_one\\_step\\_logon

Normally a two-step logon is used, first the username is requested, then the password is requested. In between the
server checks the username and is able to show alternative authentication methods based on the username. Set this to `1`
to show the username and password field at once, and disable the display of alternative authentication methods.

mod\\_authentication.is\\_signup\\_confirm

Set to `1` to force user confirmation of new accounts. This is useful when using 3rd party authentication services. If a
new identity is found then a new account is automatically added. With this option set the user will be asked if they
want to make a new account. This prevents duplicate accounts when using multiple authentication methods.

mod\\_authentication.reset\\_token\\_maxage

The maximum age of the emailed reset token in seconds. Defaults to 48 hours (172800 seconds). This must be an integer value.

mod\\_authentication.email\\_reminder\\_if\\_nomatch

On the password reset form, a user can enter their email address for receiving an email to reset their password. If a
user enters an email address that is not connected to an active account then we do not send an email. If this option is
set to `1` then an email is sent. This prevents the user waiting for an email, but enables sending emails to arbitrary addresses.

mod\\_authentication.auth\\_secret

The secret used to sign authentication cookies. This secret is automatically generated. Changing this secret will
invalidate all authentication cookies.

mod\\_authentication.auth\\_anon\\_secret

The secret to sign authentication cookies for the anonymous user. This secret is automatically generated. Changing this
secret will invalidate all authentication cookies for anonymous users.

mod\\_authentication.auth\\_user\\_secret

The secret to sign authentication cookies for the identified users if there is no database to store individual secrets.

mod\\_authentication.auth\\_autologon\\_secret

The secret to sign *remember me* cookies. This secret is automatically generated. Changing this secret will invalidate
all *remember me* cookies for all users.

Related configurations:

site.password\\_force\\_different

Set to `1` to force a user picking a different password if they reset their password.

site.ip\\_allowlist

If the admin password is set to `admin` then logon is only allowed from local IP addresses. This configuration overrules
the `ip_allowlist` global configuration and enables other IP addresses to login as `admin` if the password is set to `admin`.
").
-author("Marc Worrell <marc@worrell.nl>").

-mod_title("Authentication").
-mod_description("Handles authentication and identification of users.").
-mod_prio(500).
-mod_depends([base, acl]).
-mod_provides([authentication]).
-mod_config([
        #{
            key => password_min_length,
            type => integer,
            default => 8,
            description => "The minimum length of new passwords."
        },
        #{
            key => is_signup_confirm,
            type => boolean,
            default => true,
            description => "If true, users will have to confirm their signup by clicking on a link in an email."
        },
        #{
            key => reset_token_max_age,
            type => integer,
            default => 172_800,
            description => "The maximum age of a password reset token in seconds. Default is 2 days (172800 seconds)."
        },
        #{
            key => is_one_step_logon,
            type => boolean,
            default => false,
            description => "If true, the user must enter both their username and password at once. Otherwise they are entered one by one, "
                           "which helps checking other (external) login options."
        },
        #{
            key => is_rememberme,
            type => boolean,
            default => false,
            description => "If true, the 'remember me' option on the login form will be pre-checked. This will set a cookie that will "
                           "automatically log them in when they return to the site."
        },
        #{
            key => email_reminder_if_nomatch,
            type => boolean,
            default => false,
            description => "If true, on a password reset with a unregistered email address, an email is sent to that address with "
                           "the message that it is unknown."
        },
        #{
            key => password_disable_leak_check,
            type => boolean,
            default => false,
            description => "If true, the password leak check with haveibeenpwned.com is disabled. This is useful for testing purposes, "
                           "but must not be used in production."
        },
        #{
            key => site_auth_key,
            type => string,
            default => "",
            description => "The site authentication key is used to encrypt the tokens that can be exchanged for a valid authentication cookie or "
                           "MQTT login. It is automatically set, and must be a random string that is kept secret."
        },
        #{
            key => auth_secret,
            type => string,
            default => "",
            description => "The site authentication secret is used to encrypt the authentication cookies. "
                           "This key is used for sites without database connections, if there is a database connection then every user will "
                           "receive their own key. It is automatically set, and must be a random string that is kept secret."
        },
        #{
            key => auth_user_secret,
            type => string,
            default => "",
            description => "The authentication user secret is placed in the encrypted authentication cookies. "
                           "This secret is used for sites without database connections, if there is a database connection then every user will "
                           "receive their own secret. It is automatically set, and must be a random string that is kept secret."
        },
        #{
            key => auth_anon_secret,
            type => string,
            default => "",
            description => "The authentication secret for anonymous users is placed in the encrypted authentication cookies. "
                           "This is used to authenticate anonymous users only, the auth_user_secret is used for authenticated users. "
                           "It is automatically set, and must be a random string that is kept secret."
        },
        #{
            key => auth_autologon_secret,
            type => string,
            default => "",
            description => "The secret used to sign the autologon cookie for automatic authentication of users that checked 'remember me'. "
                           "It is automatically set, and must be a random string that is kept secret."
        }
    ]).

%% gen_server exports
-export([
    init/1,
    event/2,

    observe_request_context/3,
    observe_auth_options_update/3,
    observe_logon_submit/2,
    observe_logon_options/3,
    observe_admin_menu/3,
    observe_auth_validated/2,
    observe_auth_client_logon_user/2,
    observe_auth_client_switch_user/2,
    observe_tick_1h/2
]).

-include_lib("zotonic_core/include/zotonic.hrl").
-include_lib("zotonic_mod_admin/include/admin_menu.hrl").


init(Context) ->
    % Ensure password_min_length config
    case m_config:get(?MODULE, password_min_length, Context) of
        undefined -> m_config:set_value(?MODULE, password_min_length, "8", Context);
        _ -> nop
    end,
    ok.

event(#submit{ message={signup_confirm, Props} }, Context) ->
    {auth, Auth} = proplists:get_value(auth, Props),
    Auth1 = Auth#auth_validated{ is_signup_confirmed = true },
    case z_notifier:first(Auth1, Context) of
        undefined ->
            ?LOG_ERROR(#{
                text => <<"mod_authentication: 'undefined' return for auth">>,
                in => zotonic_mod_authentication,
                result => error,
                reason => no_auth,
                auth => Auth
            }),
            z_render:wire({show, [{target, "signup_error"}]}, Context);
        {error, Reason} ->
            ?LOG_WARNING(#{
                text => <<"mod_authentication: Error return for auth">>,
                in => zotonic_mod_authentication,
                result => error,
                reason => Reason,
                auth => Auth
            }),
            z_render:wire({show, [{target, "signup_error"}]}, Context);
        {ok, Context1} ->
            z_render:wire({script, [{script, "window.close()"}]}, Context1)
    end;
event(#postback{message={close_all_sessions, _Args}}, Context) ->
    case z_acl:user(Context) of
        undefined ->
            Context;
        UserId ->
            % Logoff and remove cookies
            % Force all user-agents connected via MQTT to logoff
            Context1 = z_auth:logoff(Context),
            Context2 = z_authentication_tokens:reset_cookies(Context1),
            % Change secrets - invalidates all existing sessions and autologon cookies.
            z_authentication_tokens:regenerate_user_secret(UserId, Context2),
            z_authentication_tokens:regenerate_user_autologon_secret(UserId, Context2),
            % Force a reload of the page (the user will have to log in again)
            z_mqtt:publish(
                [ <<"~user">>, <<"session">>, <<"logoff">> ],
                #{},
                Context),
            z_render:wire({reload, []}, Context2)
    end.

%% @doc Check for authentication cookies in the request.
-spec observe_request_context( #request_context{}, z:context(), z:context() ) -> z:context().
observe_request_context(#request_context{ phase = init }, Context, _Context) ->
    case z_context:get(anonymous, Context, false) of
        true ->
            Context;
        false ->
            Context1 = z_authentication_tokens:req_auth_cookie(Context),
            Context2 = case z_auth:is_auth(Context1) of
                false ->
                    z_authentication_tokens:req_autologon_cookie(Context1);
                true ->
                    Context1
            end,
            z_notifier:foldl(#session_context{ request_type = http, payload = undefined }, Context2, Context2)
    end;
observe_request_context(#request_context{}, Context, _Context) ->
    Context.


observe_auth_options_update(#auth_options_update{ request_options = ROpts }, AccOpts, Context) ->
    case ROpts of
        #{ <<"acl_user_groups_state">> := undefined } ->
            maps:remove(acl_user_groups_state, AccOpts);
        #{ <<"acl_user_groups_code">> := SignedCode, <<"acl_user_groups_state">> := State } ->
            case {m_acl_rule:is_valid_code(SignedCode, Context), State} of
                {true, <<"edit">>} -> AccOpts#{ acl_user_groups_state => edit };
                {true, <<"publish">>} -> maps:remove(acl_user_groups_state, AccOpts);
                {false, _} -> AccOpts
            end;
        #{} ->
            AccOpts
    end.

%% @doc Check username/password against the identity tables.
observe_logon_submit(#logon_submit{
            payload = #{
                <<"is_username_check">> := true
            }
        }, _Context) ->
    undefined;
observe_logon_submit(#logon_submit{
            payload = #{
                <<"username">> := Username,
                <<"password">> := Password
            } = Payload
        }, Context) when is_binary(Username), is_binary(Password) ->
    case m_identity:check_username_pw(Username, Password, Payload, Context) of
        {ok, 1} ->
            {ok, 1};
        {ok, UserId} ->
            case m_authentication:is_valid_password(Password, Context) of
                true ->
                    {ok, UserId};
                false ->
                    % If empty or invalid password existed in identity
                    % table then prompt for a new password.
                    {expired, UserId}
            end;
        {error, {expired, UserId}} ->
            {expired, UserId};
        {error, _} = E ->
            E
    end;
observe_logon_submit(#logon_submit{}, _Context) ->
    undefined.

observe_logon_options(#logon_options{
            payload = #{
                <<"username">> := Username,
                <<"is_username_check">> := true
            }
        },
        Acc,
        Context) when is_binary(Username) ->
    check_username(Username, Acc, Context);
observe_logon_options(#logon_options{
            payload = #{
                <<"username">> := Username,
                <<"password">> := undefined
            }
        },
        Acc,
        Context) when is_binary(Username) ->
    check_username(Username, Acc, Context);
observe_logon_options(#logon_options{}, Acc, _Context) ->
    Acc.

check_username(Username, Acc, Context) ->
    case z_string:to_lower( z_string:trim( Username ) ) of
        <<>> ->
            Acc;
        UsernameOrEmail ->
            IsUserLocal = is_user_local(UsernameOrEmail, Context)
                   orelse is_user_local_email(UsernameOrEmail, Context)
                   orelse maps:get(is_user_local, Acc, false),
            Acc#{
                is_username_checked => true,
                is_user_local => IsUserLocal,
                username => UsernameOrEmail
            }
    end.

%% @doc Send a request to the client to login a user. The zotonic.auth.worker.js will
%% send a request to controller_authentication to exchange the one time token with
%% a z,auth cookie for the given user. The client will redirect to the Url.
observe_auth_client_logon_user(#auth_client_logon_user{ user_id = UserId, url = Url }, Context) ->
    case z_context:client_topic(Context) of
        {ok, ClientTopic} ->
            case z_authentication_tokens:encode_onetime_token(UserId, Context) of
                {ok, Token} ->
                    z_mqtt:publish(
                        ClientTopic ++ [ <<"model">>, <<"auth">>, <<"post">>, <<"onetime-token">> ],
                        #{
                            token => Token,
                            url => Url
                        },
                        Context),
                    ok;
                {error, _} = Error ->
                    Error
            end;
        {error, _} = Error ->
            Error
    end.

%% @doc Send a request to the client to switch users. The zotonic.auth.worker.js will
%% send a request to controller_authentication to perform the switch.
observe_auth_client_switch_user(#auth_client_switch_user{ user_id = UserId }, Context) ->
    CurrentUser = z_acl:sudo_user(Context),
    case UserId of
        1 when CurrentUser =/= 1 ->
            % Only the admin is allowed to switch back to admin
            {error, eacces};
        _ ->
            case z_acl:is_admin(Context)
                orelse z_acl:sudo_user(Context) =:= UserId
                orelse z_acl:is_allowed(sudo_user, UserId, Context)
            of
                true ->
                    case z_context:client_topic(Context) of
                        {ok, ClientTopic} ->
                            z_mqtt:publish(
                                    ClientTopic ++ [ <<"model">>, <<"auth">>, <<"post">>, <<"switch-user">> ],
                                    #{ user_id => UserId },
                                    Context),
                            ok;
                        {error, _} = Error ->
                            Error
                    end;
                false ->
                    {error, eacces}
            end
    end.

is_user_local(<<"admin">>, _Context) ->
    true;
is_user_local(Handle, Context) when is_binary(Handle) ->
    case m_identity:lookup_by_username(Handle, Context) of
        undefined -> false;
        _Row -> true
    end.

is_user_local_email(Handle, Context) ->
    case binary:match(Handle, <<"@">>) of
        nomatch -> false;
        _ -> length(m_identity:lookup_users_by_type_and_key(email, Handle, Context)) > 0
    end.

observe_admin_menu(#admin_menu{}, Acc, Context) ->
    [
        #menu_item{
            id = admin_authentication_services,
            parent = admin_auth,
            label = ?__("External Services", Context),
            url = {admin_authentication_services},
            visiblecheck = {acl, use, mod_admin_config}}
        | Acc
    ].

%% @doc Handle a validation against an (external) authentication service.
%%      If identity is known: log on the associated user and set auth cookies.
%%      If unknown, add identity to current user or signup a new user
observe_auth_validated(#auth_validated{ is_connect = IsConnect } = Auth, Context) ->
    Context1 = z_context:set(auth_method, Auth#auth_validated.service, Context),
    case IsConnect of
        true ->
            maybe_add_identity_connect(z_acl:user(Context1), Auth, Context1);
        false ->
            maybe_add_identity_logon(Auth, Context1)
    end.

maybe_add_identity_logon(Auth, Context) ->
    case auth_identity(Auth, Context) of
        undefined ->
            VerifiedUserEmails = auth_match_email(Auth, true, Context),
            UnverifiedUserEmails = auth_match_email(Auth, false, Context),
            {VerifiedUserIds, VerifiedEmails} = lists:unzip(VerifiedUserEmails),
            {UnVerifiedUserIds, UnVerifiedEmails} = lists:unzip(UnverifiedUserEmails),
            case {lists:usort(VerifiedUserIds), lists:usort(UnVerifiedUserIds)} of
                {[], []} ->
                    % The SSO supplied email addresses do not match any locally verified
                    % email address.
                    maybe_signup(Auth, Context);
                {[1|_], _} ->
                    % Never add an external identity to the admin user during log on.
                    {error, duplicate};
                {_, [1|_]} ->
                    {error, duplicate};
                {[UserId], _} when Auth#auth_validated.is_signup_confirmed ->
                    % Local user where the user has confirmed their identity by
                    % logging in into their account.
                    {ok, _} = insert_identity(UserId, Auth, Context),
                    {ok, UserId};
                {[], [UserId]} when Auth#auth_validated.is_signup_confirmed ->
                    {ok, _} = insert_identity(UserId, Auth, Context),
                    {ok, UserId};
                {[UserId], _} when not Auth#auth_validated.is_signup_confirmed ->
                    % Local user with matching verified email identity.
                    case z_notifier:first(#auth_postcheck{
                            service = Auth#auth_validated.service,
                            id = UserId,
                            query_args = #{}
                        }, Context) of
                        {error, need_passcode} ->
                            % Local 2FA enabled - let the user enter their code
                            {error, {need_passcode, UserId}};
                        {error, set_passcode} ->
                            % Local 2FA enabled - the user needs to set their passcode
                            {error, {set_passcode, UserId}};
                        undefined ->
                            % As both SSO and local email addresses are confirmed AND there
                            % is no local 2FA enabled, add SSO identities and allow direct logon.
                            {ok, _} = insert_identity(UserId, Auth, Context),
                            {ok, UserId}
                    end;
                {[], [UserId]} when not Auth#auth_validated.is_signup_confirmed ->
                    % As the external email address is not verified, the user has to log on
                    % using their local username and password.
                    {error, {logon_confirm, UserId, hd(UnVerifiedEmails)}};
                {_, []}  ->
                    % Ambiguous - multiple matching accounts
                    {error, {multiple_email, hd(VerifiedEmails)}};
                {[], _}  ->
                    {error, {multiple_email, hd(UnVerifiedEmails)}}
            end;
        Ps when is_list(Ps) ->
            update_identity(Auth, Ps, Context)
    end.

maybe_add_identity_connect(CurrUserId, Auth, Context) ->
    case auth_identity(Auth, Context) of
        undefined ->
            % Unknown identity, add it to the current user
            {ok, _} = insert_identity(CurrUserId, Auth, Context),
            {ok, CurrUserId};
        Ps ->
            {rsc_id, IdnRscId} = proplists:lookup(rsc_id, Ps),
            if
                IdnRscId =:= CurrUserId ->
                    {ok, CurrUserId};
                true ->
                    {error, duplicate}
            end
    end.

% @doc Update the props of the matched identity record.
update_identity(Auth, IdnPs, Context) ->
    {propb, IdnPropb} = proplists:lookup(propb, IdnPs),
    {rsc_id, UserId} = proplists:lookup(rsc_id, IdnPs),
    maybe_update_identity(
        IdnPropb,
        Auth#auth_validated.service_props,
        IdnPs,
        Context),
    {ok, UserId}.

maybe_update_identity(Props, Props, _IdnPs, _Context) ->
    % props unchanged
    ok;
maybe_update_identity(_OldProps, _NewProps, [], _Context) ->
    % no identity
    ok;
maybe_update_identity(_OldProps, NewProps, IdnPs, Context) ->
    {id, IdnId} = proplists:lookup(id, IdnPs),
    m_identity:set_propb(IdnId, NewProps, Context).

maybe_signup(Auth, Context) ->
    case auth_match_primary_email(Auth, Context) of
        [] ->
            try_signup(Auth, Context);
        [Email|_] ->
            % External service uses an email address that is connected as
            % a primary email address to an account here.
            % Either the external service is not verified or our local
            % email identity is not verified.
            {error, {duplicate_email, Email}}
    end.

-spec auth_match_email(#auth_validated{}, IsVerified, Context) -> list( {UserId, Email} ) when
    IsVerified :: boolean(),
    Context :: z:context(),
    UserId :: m_rsc:resource_id(),
    Email :: binary().
auth_match_email(#auth_validated{ identities = Identities }, IsVerified, Context) ->
    Emails = lists:filtermap(
        fun
            (#{ type := <<"email">>, key := E, is_verified := IsIdnVerified }) when IsIdnVerified =:= IsVerified ->
                {true, m_identity:normalize_key(email, E)};
            (_) ->
                false
        end,
        Identities),
    find_verified_email_idns(Emails, Context).

%% Find all user ids with a verified email address matching the given email addresses.
find_verified_email_idns(Emails, Context) ->
    lists:usort(lists:flatten(lists:map(
        fun(Email) ->
            Idns = m_identity:lookup_users_by_verified_type_and_key(email, Email, Context),
            RscIds = lists:map(
                fun(Idn) ->
                    Id = proplists:get_value(rsc_id, Idn),
                    {Id, Email}
                end, Idns),
            lists:filter(fun({Id, _}) -> m_identity:is_user(Id, Context) end, RscIds)
        end,
        Emails))).



-spec auth_match_primary_email(#auth_validated{}, z:context()) -> list( Email::binary() ).
auth_match_primary_email(#auth_validated{ identities = Identities }, Context) ->
    ExtEmails = lists:filtermap(
        fun
            (#{ type := <<"email">>, key := E }) ->
                {true, m_identity:normalize_key(email, E)};
            (_) ->
                false
        end,
        Identities),
    lists:usort(lists:flatten(lists:map(
        fun(ExtEmail) ->
            Idns = m_identity:lookup_users_by_type_and_key(email, ExtEmail, Context),
            lists:filtermap(
                fun(Idn) ->
                    RscId = proplists:get_value(rsc_id, Idn),
                    PrimaryEmail = m_identity:normalize_key(email, m_rsc:p_no_acl(RscId, email_raw, Context)),
                    if
                        ExtEmail =:= PrimaryEmail -> {true, ExtEmail};
                        true -> false
                    end
                end,
                Idns)
        end,
        ExtEmails))).


try_signup(Auth, Context) ->
    case not Auth#auth_validated.is_signup_confirmed
        andalso m_config:get_boolean(mod_authentication, is_signup_confirm, Context)
    of
        true ->
            {error, signup_confirm};
        false ->
            Signup = #signup{
                id = undefined,
                signup_props = email_identities(Auth#auth_validated.identities),
                props = Auth#auth_validated.props,
                request_confirm = false
            },
            case z_notifier:first(Signup, Context) of
                {ok, NewUserId} ->
                    maybe_add_auth_identity(Auth, NewUserId, Context),
                    maybe_ensure_username_pw(Auth, NewUserId, Context),
                    {ok, NewUserId};
                {error, _Reason} = Error ->
                    Error;
                undefined ->
                    % No signup accepted
                    ?LOG_WARNING(#{
                        text => <<"Authentication not accepted because no signup handler defined for Auth">>,
                        in => zotonic_mod_authentication,
                        result => error,
                        reason => no_auth_signup,
                        auth => Auth
                    }),
                    undefined
            end
    end.

%% @doc Add the identity of the authentication provider to the user. Add the identity iff the
%% identity is not already connected to any resource.
maybe_add_auth_identity(Auth, UserId, Context) ->
    case auth_identity(Auth, Context) of
        undefined -> insert_identity(UserId, Auth, z_acl:sudo(Context));
        _ -> ok
    end.

%% @doc Ensure a username_pw identity when signing up, unless the identity service explicitly asks to not
%% add the username_pw identity.
%% @todo Delete the username_pw identity if the service asks not to add it?
maybe_ensure_username_pw(#auth_validated{ ensure_username_pw = true, is_connect = false }, UserId, Context) ->
    m_identity:ensure_username_pw(UserId, z_acl:sudo(Context));
maybe_ensure_username_pw(#auth_validated{}, _UserId, _Context) ->
    ok.

email_identities(Identities) ->
    lists:filtermap(
        fun
            (#{ type := <<"email">>, key := Email, is_verified := IsVerified }) ->
                IsUnique = false,
                Idn = {identity, {email, Email, IsUnique, IsVerified}},
                {true, Idn};
            (_) ->
                false
        end,
        Identities).

insert_identity(UserId, Auth, Context) ->
    Type = Auth#auth_validated.service,
    Key = Auth#auth_validated.service_uid,
    Props = [
        {is_unique, true},
        {is_verified, true},
        {propb, {term, Auth#auth_validated.service_props}}
    ],
    m_identity:insert(UserId, Type, Key, Props, Context).


auth_identity(#auth_validated{service=Service, service_uid=Uid}, Context) ->
    m_identity:lookup_by_type_and_key(Service, Uid, Context).

observe_tick_1h(tick_1h, Context) ->
    m_identity:cleanup_logon_history(Context).
